#!/usr/bin/env python3
# -*- coding: utf-8 -*-

__version__ = "0.1.1"

import configparser as cp
import optparse as op
import re
import os
import sys
import json
import getpass
import datetime
import subprocess

from signal import signal, SIGINT
from sys import exit

import stem
from stem.control import Controller

# Usage:
#
# Make a new directory. cd to the directory and populate it with
# a configuration file repo.cfg listing some peers.
# (Or use the script to initialize a new repo with default parameters).
#
#
# Initialize:
#   Either new (like "git init repo/"):
#     $ python oniongitshare -i
#   or from existing (like "torsocks git clone git://example7abcdefgh.onion"):
#     $ python oniongitshare -c
#
# Enjoy:
#   Run server
#     $ python oniongitshare
#   Pull from peers once
#     $ python oniongitshare -p
#   Periodically pull, don't serve
#     $ python oniongitshare -pP 1800
#   Periodically pull and also serve
#     $ python oniongitshare -P 1800
#

# Serving suggestion:
# One can simply check in a list of onions for peering
# as PEERS.txt ...

# Note:
# Using software from untrusted sources carries obvious security risks.
# Anyone can commit anything, and if one peer pulls commits, they will percolate through the swarm.
# It's important at least to verify the signatures of commits before using the code.
# Only deploy versions that are also signed off by trusted package maintainers ...

# [network]
# peers = example7abcdefgh.onion, example8abcdefgh.onion
# (possibly prefixed with somebody:authkey@ ...)

# when using -b (bare), merge remote changes locally after
# git pull origin remote/origin/master.

DEFAULT_CONTROLPORT = 9151
DEFAULT_TORSOCKS_COMMAND = "/usr/bin/torsocks"
STATUS = {'peers': None, 'socksport': None}
OPTIONS = None

tor_process = None


class color:
    RED = '\x1b[31m'
    GOLD = '\x1b[33m'
    BOLD = '\x1b[1m'
    UNSET = '\x1b[0m'
    def red(s):
        return color.RED + s + color.UNSET
    def gold(s):
        return color.GOLD + s + color.UNSET
    def bold(s):
        return color.BOLD + s + color.UNSET


def git(command, extra_kwargs={}):
    return subprocess.Popen(["git"] + command, **extra_kwargs)


def make_exportable(path):
    subprocess.Popen(["touch", os.path.abspath(os.path.join(path, "git-daemon-export-ok")) ]).wait()


def run_server(config, localport = 9418):
    hostname = config.get('onion', 'hostname')
    print ("Running git server on %s.onion" % hostname)

    peerstr = hostname + '.onion'

    try:
        authkey = config.get('onion', 'clientauth')
        if authkey:
            print ("Client auth is %s" % authkey)
            peerstr = '%s@%s.onion' % (authkey, hostname)
    except (KeyError, cp.NoOptionError) as e:
        print ("No client authentication required, repo will be public.")

    print ("Git server local port is %d" % localport)
    print ("You can now hand out the onion %s to prospective peers." % color.bold(peerstr))
    print ("It will be re-used anytime oniongitshare starts in this directory.")

    where = os.path.join(OPTIONS.a_dir, "repo")

    where += ".git"
    make_exportable(where)

    gitdaemon = git(["daemon", "--base-path=%s" % os.path.abspath(OPTIONS.a_dir),
                     "--reuseaddr", "--verbose",
                     "--disable=receive-pack",
                     "--listen=127.0.0.1", "--port=%d" % localport,
                     os.path.abspath(where)])
    gitdaemon.wait()


def makeonion(controller, config, options):

    controller.authenticate()

    onion = None

    extra_kwargs = {}
    
    if config.has_section('onion'):
        print ("Attempting to use saved onion identity.")
        (keytype,key) = config.get('onion', 'key').split(':',1)

        if options.o_auth:
            try:
                print ("Attempting to use saved clientauth.")
                extra_kwargs['basic_auth'] =\
                dict([config.get('onion', 'clientauth').split(':',1)])
            except (KeyError, cp.NoOptionError) as e:
                print ("No client auth key found, generating one")
                extra_kwargs['basic_auth'] = {'somebody': None}
        else:
            try:
                extra_kwargs['basic_auth'] =\
                dict([config.get('onion', 'clientauth').split(':',1)])
                print ("Client auth key found, using it.")
            except (KeyError, cp.NoOptionError) as e:
                print ("Not using client authentication.")

        if options.o_ap:
            print ("Waiting for onion to be published.")

        onion = controller.create_ephemeral_hidden_service(**extra_kwargs, ports={9418: options.a_localport}, discard_key=True, await_publication=options.o_ap, key_type=keytype, key_content=key)

    else:
        print ("I'm afraid we don't have an identity yet, creating one.")

        if options.o_auth:
            extra_kwargs['basic_auth'] = {'somebody': None}

        if options.o_ap:
            print ("Waiting for onion to be published.")

        onion = controller.create_ephemeral_hidden_service(**extra_kwargs, ports={9418: options.a_localport}, discard_key=False, await_publication=options.o_ap)

    print ("Onion OK.")

    if not onion.is_ok():
        raise Exception('Failed to publish onion.')
    else:

        # perhaps avoid overwriting when already present?
        for line in onion:

            if line != "OK":
                k, v = line.split('=', 1)

                # Request the key only if the service is new:
                if k == "PrivateKey":
                    try:
                        config.add_section('onion')
                    except cp.DuplicateSectionError as e:
                        pass
                    config.set('onion', 'key', v)
                if k == "ServiceID":
                    try:
                        config.add_section('onion')
                    except cp.DuplicateSectionError as e:
                        pass
                    config.set('onion', 'hostname', v)
                if k == "ClientAuth":
                    try:
                        config.add_section('onion')
                    except cp.DuplicateSectionError as e:
                        pass
                    config.set('onion', 'clientauth', v)
            config.write(open(os.path.join(OPTIONS.a_dir, 'repo.cfg'), 'w'))
    return True


def authenticate(controller):    
    try:
        controller.authenticate()
    except stem.connection.MissingPassword:
      controller_password = getpass.getpass('Controller password: ')
      controller.authenticate(password = controller_password)


def set_client_authentications(ls):
    global OPTIONS
    options = OPTIONS

    controller = Controller.from_port(port = options.a_controlport)

    authenticate(controller)

    # is there no sane way to _append_ a multi-config option in Tor????
    controller.set_caching(False)

# except it doesn't work, the 650 message never arrives. why?
#    controller.add_event_listener(my_confchanged_listener, EventType.CONF_CHANGED)
# SETEVENTS conf_changed

    hsa = controller.get_conf_map('hidservauth') 

    for authpair in ls:
        if authpair['auth'] and len(authpair['auth']):
            hsa['hidservauth'].append('%s.onion %s' % (authpair['onion'], authpair['auth']))

    hsa['hidservauth'] = list(set(hsa['hidservauth']))

    controller.set_conf('hidservauth', hsa['hidservauth'])
    controller.close()
    

def getpeers(config):
    if STATUS['peers']:
        return STATUS['peers']

    if config.has_section('network'):
        peerslist = config.get('network', 'peers').split(',')
        peers = []
        authpairs = []

        for peerentry in peerslist:

            # extract what looks like an onion identifier
            try:
                authpair = re.findall('(?:(somebody:[A-Za-z0-9+/]+)@)?([a-z2-8]+)', peerentry)[0]

                userpass = authpair[0].split(":",1)
                if not userpass or not len(userpass)==2:
                    userpass = (None, None)

                authpairs += [{'auth':userpass[1],
                               'user':userpass[0], # somebody
                               'onion':authpair[1]}]
                peers += [authpair[1]]

            except Exception as e:
                print (e)

        set_client_authentications(authpairs)

        STATUS['peers'] = peers

        return peers

    else:
        STATUS['peers'] = []

        return []


def clone(config):
    peers = getpeers(config)
    for peer in peers:
        if attempt_clone_from(peer):
            return True
    return False


def attempt_clone_from(peer_onion):

    remote_repo_uri  = "git://%s.onion/repo" % peer_onion
    where = os.path.join(OPTIONS.a_dir, "repo")
    how   = []

    remote_repo_uri += ".git"
    where += ".git"
    how   = ["--bare"]

    print("Cloning from %s" % remote_repo_uri)

    cloneproc = subprocess.Popen([OPTIONS.a_torsocks_command, "-P", STATUS['socksport'], "git", "clone"] + how + [remote_repo_uri, where])
    if cloneproc.wait() != 0:
        print ("Error cloning from %s." % peer_onion)
        return False
    else:
        make_exportable(where)

    print("Adding remote %s." % peer_onion)
    proc_setMirror = subprocess.Popen([OPTIONS.a_torsocks_command, "-P", STATUS['socksport'], "git", "-C", os.path.abspath(where), "remote", "add", peer_onion, remote_repo_uri], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    # "--mirror=fetch",
    proc_setMirror.wait()
    # ("fatal: remote xxx already exists" is expected)

    proc_setRefs = subprocess.Popen([OPTIONS.a_torsocks_command, "-P", STATUS['socksport'], "git", "-C", os.path.abspath(os.path.join(OPTIONS.a_dir, "repo.git")), "config", "remote.%s.fetch" % peer_onion, "+refs/heads/*:refs/remotes/origin/*"])
    proc_setRefs.wait()

    # Make a local editable repo
    git(["clone", os.path.join(OPTIONS.a_dir, "repo.git"),
         os.path.join(OPTIONS.a_dir, "repo")]).wait()

    return True


def fetch(config):
    peers = getpeers(config)
    print ("Fetching from %s at %s" % (peers, datetime.datetime.now().strftime("%H:%M:%S")))
    processes = []
    for peer in peers:
        remote_repo_uri = "git://%s.onion/repo.git" % peer

        proc_setMirror = subprocess.Popen([OPTIONS.a_torsocks_command, "-P", STATUS['socksport'], "git", "-C", os.path.abspath(os.path.join(OPTIONS.a_dir, "repo.git")), "remote", "add",  peer, remote_repo_uri], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        proc_setMirror.wait()
        # "--mirror=fetch",

        proc_setRefs = subprocess.Popen([OPTIONS.a_torsocks_command, "-P", STATUS['socksport'], "git", "-C", os.path.abspath(os.path.join(OPTIONS.a_dir, "repo.git")), "config", "remote.%s.fetch" % peer, "+refs/heads/*:refs/remotes/origin/*"])
        proc_setRefs.wait()

        processes.append([peer, subprocess.Popen([OPTIONS.a_torsocks_command, "-P", STATUS['socksport'], "git", "-C", os.path.abspath(os.path.join(OPTIONS.a_dir, "repo.git")), "fetch", peer], stdout=subprocess.PIPE, stderr=subprocess.PIPE)])

        processes.append([peer, subprocess.Popen([OPTIONS.a_torsocks_command, "-P", STATUS['socksport'], "git", "-C", os.path.abspath(os.path.join(OPTIONS.a_dir, "repo.git")), "remote", "update", peer], stdout=subprocess.PIPE, stderr=subprocess.PIPE)])

    for (peer, proc) in processes:
        so, se = proc.communicate()
        if proc.returncode != 0:
            print ("Error fetching from %s." % peer)
        else:
            print ("Fetch from %s successful." % peer)


def init(config):
    print ("Initializing ...")

    git(["init", os.path.join(OPTIONS.a_dir, "repo.git"), "--bare"]).wait()
    # Make a local editable repo:
    git(["clone", os.path.join(OPTIONS.a_dir, "repo.git"), os.path.join(OPTIONS.a_dir, "repo")]).wait()

    print ("Initialized")


def cleanup():
    global tor_process
    if tor_process is not None:
        tor_process.kill()


def handler(signal_received, frame):
    cleanup()
    print('SIGINT or CTRL-C detected. Exiting gracefully')
    exit(0)


def main(args=[]):
    # OptionParser is capable of printing a helpscreen
    opt = op.OptionParser()

    opt.add_option("-V", "--version", dest="o_version", action="store_true",
                   default=False, help="print version number")

    opt.add_option("-i", "--init", dest="o_init", action="store_true",
                   default=False, help="make new empty repo")

    # opt.add_option("-B", "--non-bare", dest="o_bare", action="store_false",
    # default=True, help="use non-bare repo directly and pull, not fetch (deprecated, not recommended)")

    opt.add_option("-c", "--clone", dest="o_clone", action="store_true",
                   default=False, help="clone repo from 1st peer")

    opt.add_option("-p", "--pull", dest="o_pull", action="store_true",
                   default=False, help="pull / fetch from peers and don't serve")

    opt.add_option("-P", "--periodically-pull", dest="a_pull", action="store",
                   type="int", default=None, metavar="PERIOD",
                   help="pull / fetch from peers every n seconds")

    opt.add_option("-L", "--local", dest="a_localport", action="store", type="int",
                   default=9418, metavar="PORT", help="local port for git daemon")

    opt.add_option("-C", "--control-port", dest="a_controlport", action="store", type="int",
                   default=9151,  metavar="PORT", help="Tor controlport")

    opt.add_option("-T", "--private-tor-daemon", dest="a_tordaemon", action="store", type="string", default=None, metavar="PATH", help="use private tor daemon")

    opt.add_option("-X", "--use-existing-torrc", dest="o_use_existing_torrc", action="store_true", default=False, help="(with -T): do not insist on creating new [DIR]/tor/tocc, use existing")
    opt.add_option("-S", "--torsocks-command", dest="a_torsocks_command", action="store", type="string", default=DEFAULT_TORSOCKS_COMMAND, metavar="PATH", help="use this command to torify git (default: %s)" % DEFAULT_TORSOCKS_COMMAND)

    opt.add_option("-a", "--await", dest="o_ap", action="store_true",
                   default=False, help="await publication of .onion in DHT before proceeding")

    opt.add_option("-x", "--auth", action="store_true", default=False,
                   dest="o_auth", help="enable authentication (private onion address that can only be accessed with a password)")

    opt.add_option('-d', "--dir", dest="a_dir", action="store", type="string", default=".", metavar="DIR", help="base directory")

    (options, args) = opt.parse_args(args)

    global OPTIONS
    OPTIONS = options

    print(color.gold('This is oniongitshare V%s') % __version__)

    if options.o_version:
        print (__version__)
        return 0

    if options.o_auth and stem.__version__ < '1.5.0':
        sys.stderr.write ("Error: stem version >=1.5.0 required for auth\n")
        return 1

    if not options.a_controlport:
        options.a_controlport = DEFAULT_CONTROLPORT

    if not os.path.isdir(options.a_dir):
        sys.stderr.write ("Error: directory %s does not exist\n" % options.a_dir)
        return 1

    # Start tor process if requested (-T)
    global tor_process

    if options.a_tordaemon:
        base_dir = options.a_dir
        local_tor_dir = os.path.join(base_dir, "tor")
        try:
            os.mkdir(local_tor_dir)    
        except FileExistsError as e:
            print ("%s already exists" % local_tor_dir)
        torrc_path = os.path.join(local_tor_dir, "torrc")
        if not options.o_use_existing_torrc:
            torrc = open(torrc_path, "w")
            DESIRED_SOCKSPORT = 9250
            torrc.write("SocksPort %d\n" % DESIRED_SOCKSPORT)
            torrc.write("DataDirectory %s\n"
                        % os.path.abspath(os.path.join(local_tor_dir, "tor")))
            torrc.write("ControlPort %s\n" % options.a_controlport)
            torrc.write("CookieAuthentication 1\n")
            torrc.close()
        torrc_abspath = os.path.abspath(torrc_path)
        tor_process = subprocess.Popen([options.a_tordaemon,
                                        "-f", torrc_path],
#                                       text=True,
                                       stdout=subprocess.PIPE,
                                       stderr=subprocess.STDOUT)
        
        for line in tor_process.stdout:
            # older versions don't have this line (?)
            # it would be more proper to wait for the tor process to open the port, anyway.
            # if re.search(rb"\[notice\] Opened Control listener on 127\.0\.0\.1:%d" % options.a_controlport, line):
            if re.search(rb"Bootstrapped 100%", line):
                print ("spawned tor process ready")
                break
            else:
                print (line.decode(errors='ignore').rstrip())


    # Extract socksport via c.get_conf and use this (-P in torsocks)
    controller = Controller.from_port(port = options.a_controlport)
    authenticate(controller)

    try:
        STATUS['socksport'] = controller.get_conf('SocksPort').split(" ",1)[0]
    except AttributeError as e:
        print("Error: tor controller present at %d but SocksPort not set." % options.a_controlport)
        return 1
    controller.close()

    config = cp.ConfigParser()
    cfgfile = None
    try:
        cfgfile = open(os.path.join(options.a_dir, 'repo.cfg'), "r")
    except FileNotFoundError as e:
        print("Trying to make file repo.cfg")
        try:
            os.mknod(os.path.join(options.a_dir, 'repo.cfg'))
            os.chmod(os.path.join(options.a_dir, 'repo.cfg'), 0o600)
            cfgfile = open(os.path.join(options.a_dir, 'repo.cfg'))
        except Exception as e:
            print (e)
            return 1

    config.readfp(cfgfile)

    try:
        os.stat(os.path.join(options.a_dir, "repo.git"))

    except FileNotFoundError as e:
        if not options.o_init and not options.o_clone: # and options.o_bare:
            print ("./repo.git/ does not exist, try -i or -c")
            return 1

    try:
        os.stat(os.path.join(options.a_dir, "repo"))

    except FileNotFoundError as e:
        # warn or info
        print ("no ./repo local checkout found")
        # if not options.o_init and not options.o_clone and not options.o_bare:
        #    print("./repo/ does not exist, try -i or -c")
        #    return 1

    except Exception as e:
        print (e)
        return 1

    if options.o_init:
        init(config)

    peers = getpeers(config)

    if options.o_clone:
        if not len(peers):
            print ("No peers, can't clone. Please enter a peer in repo.cfg")
        clone(config)
        return 1

    threads = []

    if options.a_pull:
        if not len(peers):
            print ("No peers, not starting pulling task.")

        else:
            import threading
            from   datetime import timedelta as td
            from   datetime import datetime
            
            class T:
                def __init__(self):
                    self.last = datetime.now()
                    
                def run(self):
                    fetch(config)
                    # TODO: pull only works in a work-tree.
                    # How do we update the (master) branch?
                    threading.Timer(options.a_pull, T.run, args=(self,)).start()
                    
            task = T()

            t = threading.Thread(target=T.run, args=(task,))
            t . setDaemon(True)
            threads.append(t)
            t.start()

    if options.o_pull and not options.a_pull:
        fetch(config)
        
    elif not options.o_pull:
        controller = Controller.from_port(port = options.a_controlport)
        makeonion(controller, config, options)
        run_server(config, localport = options.a_localport)
        controller.close()

    for t in threads:
        t.join()

    cleanup()
